06 - 前缀缓存与排布
前置:03、04、05 篇。那三篇里的每一招都会动到请求前缀。
本篇回答:为什么"token 省下来了、账单反而涨了",以及内容该按什么顺序排。
本篇会用到的词:
| 词 | 意思 |
|---|---|
| 前缀匹配 | 缓存按请求开头的字节序列匹配。前缀里任何一个字节变了,从那里往后全部要重算 |
| 渲染顺序 | 请求拼成 token 序列的固定顺序:tools → system → messages |
| 断点(breakpoint) | cache_control 标记,告诉服务在这个位置之前的内容做缓存。每次请求最多 4 个 |
| 缓存写入 / 读取 | 首次建立缓存的价格约是常规输入的 1.25 倍,命中读取约 0.1 倍 |
| 会话中系统消息 | 把操作指令作为 {"role": "system"} 追加进 messages,而不是改顶层 system |
一、机制:一个字节定生死
价格结构决定了这件事有多重要:
| 类型 | 相对常规输入的价格 |
|---|---|
| 缓存读取(命中) | 约 0.1 倍 |
| 缓存写入(首次建立) | 约 1.25 倍 |
| 未缓存 | 1 倍 |
也就是说,一次缓存失效的代价,相当于把那部分内容按 12.5 倍于命中价重付一次。
二、断点放哪
最多 4 个 cache_control 断点。三种典型放置:
// 模式 A:大系统提示词,多个请求共享 —— 断点放最后一个 system 文本块
// tools 渲染在 system 之前,所以这一个断点把 tools + system 一起缓存了
system: [{ type: "text", text: LARGE_SYSTEM, cache_control: { type: "ephemeral" } }]
// 模式 B:多轮对话 —— 断点放最新一轮的最后一个内容块
// 每次请求复用之前全部前缀;更早的断点仍是有效读取点,命中随对话增长而累积
messages[messages.length - 1].content.at(-1).cache_control = { type: "ephemeral" };
// 模式 C:共享前缀 + 变化后缀(少样本示例、检索文档 + 不同问题)
// ⚠️ 断点要放在【共享部分】的末尾,不是整个提示词的末尾。
// 放在末尾的话,每个请求都会写入一份互不相同的缓存条目,永远读不到。
两个容易忽略的限制:
- 最小可缓存前缀约 1,024 token(随模型不同)。比这短的前缀即使标了断点也不会缓存,不报错,只是
cache_creation_input_tokens一直是 0 - 默认 TTL 5 分钟,可以设
ttl: "1h"。用户思考几分钟再发下一句,就已经过期了 —— 交互式产品要考虑这一条
三、六类静默失效
这些全都不报错。审计时直接对着这张表 grep 喂进前缀的那些代码:
| 模式 | 为什么破缓存 | grep 什么 |
|---|---|---|
| 系统提示词里有当前时间 | 每次请求前缀都不同 | datetime.now() / Date.now() / time.time() |
| 靠前的位置有请求 ID 或 UUID | 同上 | uuid4() / crypto.randomUUID() |
| JSON 序列化没固定键顺序 | 字节序列不确定 | json.dumps( 没带 sort_keys=True;遍历 set |
| 系统提示词里插了用户 ID | 每个用户一份前缀,跨用户完全不共享 | 系统提示词里的 f-string 插值 |
| 条件拼接的系统提示词 | 每种开关组合是一个不同前 缀 | if flag: system += ... |
| 按用户动态生成工具集 | 工具渲染在位置 0,跨用户零共享 | tools=build_tools(user) |
最后两条在 Agent 里特别常见,因为"按角色给不同工具""按功能开关拼提示词"都是很自然的写法。修法是把动态那部分挪到最后一个断点之后,而不是删掉它。
3.1 会话中途要下指令时,不要改顶层 system
一个具体场景:对话进行到第 20 轮,要切换成简洁模式。直觉写法是改 system —— 那会把 20 轮的缓存全部打掉。
正确做法是把指令作为一条 system 角色的消息追加进 messages:
// 无需 beta 头。支持的模型:Claude Opus 5、Opus 4.8、Fable 5、Mythos 5;Sonnet 5 不支持
// 位置约束:必须跟在一条 user 消息之后(或跟在以服务端工具调用结尾的 assistant 消息之后),
// 且必须是 messages 的最后一条、或者后面紧跟一个 assistant 轮;不能是 messages[0]
const response = await client.messages.create({
model: "claude-opus-5",
max_tokens: 16000,
system: [{ type: "text", text: STABLE_SYSTEM, cache_control: { type: "ephemeral" } }],
messages: [
...history, // 这一整段仍然命中缓存
{ role: "user", content: userMessage },
{ role: "system", content: "简洁模式:回答控制在 40 字以内。" },
],
});
它同时还是更安全的通道:操作方指令走这条路,和用户输入在结构上是分开的,不会和对话内容混在一起。
四、前三篇每一招对缓存做了什么
五、破缓存值不值:算一笔账
clear_at_least 这个参数存在的理由,用数字说最清楚。设前缀 80,000 token 已缓存,某次裁剪清掉 X token,之后连续 N 轮复用新前缀:
- 不裁剪:每轮付
80,000 × 0.1 - 裁剪:本轮付
(80,000 − X) × 1.25(缓存写入),之后每轮付(80,000 − X) × 0.1
clear_at_least 的默认值"无"是一个需要主动改掉的设置。可操作的取值:clear_at_least 设成当前前缀的 30% 到 50%。低于这个量级的清理,宁可让上下文再涨一会儿。
六、怎么验证
// 每次请求都看这三个字段,它们是这一层唯一可靠的反馈
console.log(response.usage.cache_read_input_tokens); // 命中读回的(约 0.1 倍价)
console.log(response.usage.cache_creation_input_tokens); // 写入的(约 1.25 倍价)
console.log(response.usage.input_tokens); // 完全没缓存的(1 倍价)
三种异常读法:
| 现象 | 结论 |
|---|---|
cache_read 长期为 0,cache_creation 一直有值 | 前缀每次都在变 —— 对照第三节那六类,最常见是时间戳和动态工具集 |
cache_read 和 cache_creation 都是 0 | 前缀短于最小可缓存长度,断点没生效 |
cache_read 正常,但账单仍高 | 缓存没问题,问题在别处 —— 多半是压缩迭代没算进来(03 篇那个坑) |
需要更细的定位时有一个专门的诊断 beta(cache-diagnosis-2026-04-07):走 client.beta.messages.*,第一轮传 diagnostics: {previous_message_id: null},之后每轮传上一次响应的 id,结果在 response.diagnostics 里。它能直接告诉你前缀是在哪里断开的。
七、排布原则
把缓存约束和02 篇的位置效应合起来,得到一份排布顺序:
| 位置 | 放什么 | 依据 |
|---|---|---|
| 最前(tools) | 稳定的工具集;高频工具非延迟,其余延迟加载 | 位置 0,任何变化代价最大 |
| 前(system) | 不随请求变化的角色、约束、输出格式 | 缓存友好 + 位置效应的开头优势 |
| 中(历史) | 对话历史、工具结果 —— 也就是可以被裁剪和压缩的那些 | 中间段本来就最容易被模型忽略,砍在这里损失最小 |
| 后 | 检索结果、记忆注入 | 每轮都变,放前面会打掉全部缓存 |
| 最后 | 本轮用户输入、会话中系统指令 | 位置效应的结尾优势 + 完全不影响缓存 |
一条经常被违反的推论:记忆和检索结果不要拼进系统提示词。这是很自然的直觉("这是背景信息,当然属于 system"),也是把缓存命中率打到 0 最快的方式。同一条结论在记忆专题 04 篇第五节从另一个方向讲了一遍。
八、小结
- 缓存按字节前缀匹配,一次失效相当于把那部分按 12.5 倍于命中价重付一次
- 六类静默失效里,"按用户拼工具集"和"条件拼接系统提示词"在 Agent 里最常见,修法是挪到最后一个断点之后而不是删掉
- 会话中途下指令要用
messages里的system角色消息,不要改顶层system - 四种手段里工具搜索对缓存最友好且省得最多,应该最先做;压缩虽然必然重建缓存,但频率低且重建后的前缀更短
- 小额裁剪几乎一定是亏的:从 80K 前缀里清 5K 要一百多轮才回本,
clear_at_least建议设成前缀的 30% 到 50% - 每次请求盯
cache_read_input_tokens,长期为 0 就是前缀在变 - 排布顺序:稳定的在前、可砍的在中、每轮变的在后、本轮输入在最末
下一篇:07 - 观测与落地,怎么知道前面这些改动到底起没起作用。
← 回到 专题索引